Skip to content

feat(notifications): Linear dispatcher in fanout consumer (cost/turns/duration)#243

Merged
krokoko merged 4 commits into
aws-samples:mainfrom
isadeks:feat/239-linear-fanout-dispatcher
Jun 10, 2026
Merged

feat(notifications): Linear dispatcher in fanout consumer (cost/turns/duration)#243
krokoko merged 4 commits into
aws-samples:mainfrom
isadeks:feat/239-linear-fanout-dispatcher

Conversation

@isadeks

@isadeks isadeks commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Closes #239. Stacked on #241 (the screenshot pipeline) — review #241 first; this PR is the dispatcher that composes with it for the screenshot-from-Linear-issue feedback loop.

Summary

  • New Linear dispatcher in fanout-task-events.ts, mirroring the slack/github/email pattern. Single-entry registration via the existing NotificationChannel + CHANNEL_DEFAULTS + DISPATCHERS extension surface.
  • Posts a deterministic final-status comment to the linked Linear issue on terminal task events. Three framing modes: ✅ task_completed, ⚠️ error_max_turns-with-PR, ❌ all other terminal states.
  • Catches the case where the agent crashes (OOM, SDK buffer overflow, max_turns) before reaching its own step-3 completion comment — see ABCA-91 in feat(notifications): platform-side final-status Linear comment with cost/turns/duration #239's description.
  • Reuses the existing postIssueComment helper; no new outbound Linear API surface.

Architecture

  • Channel registration: 1 line in NotificationChannel, 1 entry in CHANNEL_DEFAULTS (terminal events only), 1 entry in DISPATCHERS map.
  • dispatchToLinear gates on channel_source === 'linear' so non-Linear tasks short-circuit after one DDB Get — same shape as the Slack dispatcher's gate.
  • renderLinearFinalStatusComment is exported for testability and cleanly separates the (event_type, pr_url) → header logic from the body assembly.
  • Construct gets two new guarded props (linearWorkspaceRegistryTable, linearOauthSecretArnPattern) matching the slackSecretArnPattern pattern. A deployment without LinearIntegration gets no dangling IAM grants.
  • FanOutConsumer moved below LinearIntegration in agent.ts so it can receive the registry table reference (LinearIntegration depends on runtime and orchestrator, both already constructed earlier).

Test plan

  • 6 new Linear-dispatcher tests: happy path, failed without PR, ABCA-91 max_turns-with-PR, channel_source short-circuit, missing channel_metadata, postIssueComment graceful no-op
  • Existing routing tests updated for the new dispatcher count (4 channels: slack/github/linear/email)
  • Full CDK suite: 1816 passing, 101 suites (15 min on M1)
  • CI: build + tests pass on PR
  • Smoke test on dev stack (deferred until feat(notifications): preview-deploy screenshot pipeline (provider-agnostic) #241 lands so this doesn't fight against the in-flight stack)

Out of scope (deferred per #239)

  • Linear reaction-flip logic (the ❌ vs ⚠️ on the original issue)
  • Migrating the agent's three-comment prompt contract to platform-side (step 3 in the prompt becomes redundant once this lands)
  • Real-time progress comments — Linear's save_comment doesn't support edit, so this is post-once on terminal events only

@isadeks isadeks requested a review from a team as a code owner June 2, 2026 22:17
@krokoko

krokoko commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Looking 👁️

@krokoko

krokoko commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review — Linear dispatcher in fanout consumer (#239)

Thanks for this — it's a clean, well-tested addition and it closes a real UX gap. The ABCA-91 case (agent crashes at max_turns after shipping a PR, leaving the Linear requester with only a ❌ reaction and no metrics) is exactly the kind of "outcomes must stay inspectable" gap worth closing, and doing it platform-side so it fires even when the agent never reaches its own step-3 comment is the right call.

A few things I especially liked:

  • The dispatcher reuses the existing NotificationChannel + CHANNEL_DEFAULTS + DISPATCHERS surface and mirrors Slack's channel_source gate and best-effort failure contract — same idiom, real per-channel substance, no copy-paste.
  • Numerics are coerced at the DDB boundary with coerceNumericOrNull, matching the GitHub dispatcher.
  • IAM grants are guarded behind optional props, so a deployment without Linear onboarding gets no dangling permissions.
  • The mockDdbSend refactor from index-based assertions to command-type filtering is the correct defensive fix now that GitHub + Linear both hit the shared Get mock.
  • Docs are in good shape — I ran node scripts/sync-starlight.mjs on the branch and the Starlight mirror is in sync, so CI's mutation guard should pass.

Verdict: Approve with nits. One item I'd like scoped down before merge, plus some polish.

Please address before merge

1. bedrock-agentcore:* action wildcard — least privilege (cdk/src/constructs/github-screenshot-integration.ts:195)

actions: ['bedrock-agentcore:*'],
resources: ['*'],

The handler only calls StartBrowserSession, StopBrowserSession, and the SigV4-presigned ConnectBrowserAutomationStream WSS endpoint. resources: ['*'] is defensible (ephemeral sessions have no stable ARN, and the IAM5 suppression documents it), but the action wildcard grants every AgentCore action (memory, runtime, gateway, identity, code-interpreter) we don't use. I see the commit history notes this was widened to unblock e2e with a "scope back down" follow-up — this is a good moment to do it:

actions: [
  'bedrock-agentcore:StartBrowserSession',
  'bedrock-agentcore:StopBrowserSession',
  'bedrock-agentcore:ConnectBrowserAutomationStream',
],

If ConnectBrowserAutomationStream can't be confirmed as the exact IAM action name (it wasn't in the public CLI list), CloudTrail's decoded deny message will have it. If it genuinely can't be resolved, keeping the wildcard is acceptable as long as the blocker is documented inline with a tracking issue rather than a bare "followup."

Non-blocking nits

  1. Doc-vs-code mismatch on the ⚠️ frame (renderLinearFinalStatusComment JSDoc, fanout-task-events.ts). The comment keys case 2 on error_max_turns as an eventType, but error_max_turns is an agent_status, never an event_type — the real trigger is !isCompleted && prUrl != null (any terminal failure that still produced a PR). The test correctly passes event_type: 'task_failed'. Worth aligning the comment to the real condition.

  2. classifyError returns UNKNOWN_CLASSIFICATION, not null, for unmatched non-empty messages (error-classifier.ts:359). The dispatcher's inline comment says it "returns null … the renderer falls back to a generic frame," but a failed task with any non-empty unmatched error_message will actually render ❌ Task failed: Unexpected error. Not a bug — just pick the intended behavior and align the comment with it.

  3. The classifier title is dropped on the ⚠️ path. For the ABCA-91 case (max-turns + PR), classification.title ("Exceeded max turns") is computed but not rendered — the requester sees the PR but not why it stopped, which is the most useful context for the motivating issue. Consider appending it to the ⚠️ frame.

  4. Log severity (dispatchToLinear): the missing_env and post_failed paths log at INFO, whereas the GitHub dispatcher's equivalent missing-env path is WARN. Since this comment is the only completion signal for the crash case, a misconfigured env var or revoked OAuth token failing every post is worth a WARN + error_id so it's alarmable. (The underlying linear-feedback.ts failures already WARN, so this is a backstop.)

  5. Stale comment in routeEvent (fanout-task-events.ts): "at most 3 channels" — now 4 (slack/github/linear/email).

  6. Output description leftover (agent.ts:883): one ScreenshotBucketName description still reads "Vercel-preview screenshots" after the de-Vercel-ize sweep. Cosmetic.

Tests

Coverage is solid and the suite passes locally (86 tests in fanout-task-events.test.ts). A few gaps the issue's acceptance criteria named that would be cheap to close:

  • Missing LINEAR_WORKSPACE_REGISTRY_TABLE_NAME env path — the env is set at module load and never unset, so the deploy-misconfig safety valve is unexercised.
  • renderLinearFinalStatusComment null-metric rendering — every fixture supplies non-null metrics, so the fallbacks and formatDuration boundaries (<60s, exact-minute Nm) aren't covered. This is exactly the crash-before-metrics case the feature is built for; a small table-driven test calling the exported renderer directly would do it.
  • error_max_turns without pr_url — the ⚠️↔❌ boundary (the prUrl != null flip) isn't asserted in that direction.

(The missing-OAuth-token graceful no-op, AC item 5, is covered at the helper layer in linear-feedback.test.ts — no need to duplicate it here.)

One small heads-up: since the PR is stacked on #241, the diff against main carries the full screenshot pipeline too — happy to review just the #239 delta if you retarget the base, otherwise no worries.

Nice work overall — scope the IAM action and this is good to go.


Reviewed with the help of automated agents (code-reviewer, silent-failure-hunter, pr-test-analyzer). 🤖

@isadeks isadeks marked this pull request as draft June 2, 2026 23:23
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@isadeks isadeks marked this pull request as ready for review June 5, 2026 16:20
@isadeks

isadeks commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

@krokoko thanks for the review. All items addressed across two review-response commits.

Shipped on this branch

Blocking item (1) — bedrock-agentcore:* action wildcard (d8d9479)
Narrowed github-screenshot-integration.ts:195 to the three actions the handler actually calls: StartBrowserSession, StopBrowserSession, ConnectBrowserAutomationStream. Resource wildcard stays (sessions are ephemeral, no per-resource ARN); the cdk-nag IAM5 suppression already documents that.

Non-blocking nits (3280d2c, b84ce1e, d8d9479)

# Item Where
2 JSDoc on renderLinearFinalStatusComment keyed on the real (eventType, prUrl) discriminator instead of error_max_turns 3280d2c
3 classifyError inline comment now correctly says UNKNOWN_CLASSIFICATION (not null) for non-empty unmatched messages 3280d2c
4 ⚠️ frame appends classifier title — for ABCA-91 the requester sees Shipped a PR but stopped early — Exceeded max turns 3280d2c
5 missing_env and post_failed paths bumped from INFO to WARN with error_id tags so they're alarmable 3280d2c
6 "at most 3 channels" → "4 channels" in routeEvent 3280d2c
7 ScreenshotBucketName description: "Vercel-preview screenshots" → "deploy-preview screenshots" d8d9479

Test gaps (3280d2c)

  • New test: LINEAR_WORKSPACE_REGISTRY_TABLE_NAME unset → WARN + skip (deploy-misconfig safety valve)
  • New test: error_max_turns without pr_url renders ❌ not ⚠️ (the boundary in the other direction)
  • New describe block: 8 direct tests of renderLinearFinalStatusComment covering null-metric fallbacks, formatDuration boundaries (<60s, exact-minute Nm, mixed Nm Ss), classifier-title rendering on ⚠️ and ❌ frames, and the no-trailing-colon when errorTitle is null

102 tests in fanout-task-events.test.ts (was 92), full suite still green.

Bonus from first dev smoke-test (b84ce1e)

After deploying to dev I noticed two near-duplicate signals on the Linear thread:

  • ✅ comment carried PR: <url> while the agent's step-2 had already posted the same link → render PR URL only on ⚠️ now
  • Agent step-3 "task completed" stacked next to the platform's structured ✅ → removed step 3 from the Linear-channel prompt contract; agent now posts start (1) + PR-opened (2), platform posts terminal ✅/⚠️/❌ (3)

You'd predicted this exact migration in your review ("the agent prompt can drop step 3 once the platform side is reliable").

Heads-up

PR is stacked on PR #241 — the diff against main carries the screenshot pipeline too. Happy to retarget the base for review purposes if it'd help; otherwise the #239 delta is the three commits 3ba880d, 3280d2c, b84ce1e, d8d9479.

Re-requesting review.

isadeks added 3 commits June 10, 2026 15:40
…ost/turns/duration

Closes aws-samples#239.

Adds a Linear dispatcher to the fanout consumer alongside the existing
slack/github/email dispatchers. Posts a deterministic final-status
comment on terminal task events for Linear-origin tasks, with cost,
turns/max_turns, duration, and PR link rendered.

Three framing modes by (event_type, pr_url):

  ✅ task_completed                 → 'Task completed'
  ⚠️ error_max_turns + pr_url       → 'Shipped a PR but stopped early'
  ❌ all other terminal states      → 'Task <subtype>: <classifier title>'

The ⚠️ case is the motivating ABCA-91 scenario: agent hit max_turns on
turn 101 after shipping PR aws-samples#35; previous behaviour was a silent ❌
reaction with no metrics surfaced to the requester. The platform-side
comment fires deterministically even when the agent crashes (OOM,
SDK buffer overflow, max_turns) before reaching its own step-3
completion comment.

Architecture:
- One new entry in NotificationChannel + CHANNEL_DEFAULTS + DISPATCHERS
  in fanout-task-events.ts. Dispatcher gates on channel_source ===
  'linear' so non-Linear tasks short-circuit after one DDB Get.
- Reuses the existing postIssueComment helper from
  shared/linear-feedback.ts (already in use by the screenshot pipeline
  + orchestrator failure-reporting paths).
- New construct props linearWorkspaceRegistryTable + linearOauthSecretArnPattern
  guard the IAM grants the same way slackSecretArnPattern does — a
  deployment without LinearIntegration gets no dangling permission to
  bgagent-linear-oauth-*.
- FanOutConsumer instantiation moved below LinearIntegration in agent.ts
  so it can receive the registry table reference.

Tests: 92 passing in fanout-task-events.test.ts (1816 across full
CDK suite). New Linear-dispatcher describe block covers happy path,
failed without PR, ABCA-91 max_turns-with-PR, channel_source short-
circuit, missing metadata, and postIssueComment-returning-false
graceful no-op.
Addresses the non-blocking nits from aws-samples#239 review:

- JSDoc on renderLinearFinalStatusComment now describes the actual
  (eventType, prUrl) discriminator rather than 'error_max_turns' as
  an event type. The agent_status discrimination lives in the error
  classifier, not in the dispatcher's framing logic.
- Inline comment on classifyError result corrected: returns null only
  for empty error_message, UNKNOWN_CLASSIFICATION (title 'Unexpected
  error') for any non-empty unmatched message.
- ⚠️ frame now appends classifier title — for ABCA-91 the requester
  sees 'Shipped a PR but stopped early — Exceeded max turns', not
  just the bare PR-shipped frame.
- missing_env and post_failed log paths bumped from INFO to WARN
  with error_id tags so missing-env / post-failure are alarmable.
  The Linear comment is the only completion signal for the
  agent-crash case, so silent drops defeat the dispatcher's purpose.
- Stale 'at most 3 channels' comment in routeEvent updated to 4.

Test coverage:
- New test: LINEAR_WORKSPACE_REGISTRY_TABLE_NAME unset → WARN + skip
  (the deploy-misconfig safety valve was unexercised).
- New test: error_max_turns WITHOUT pr_url renders ❌, not ⚠️
  (the ⚠️↔❌ boundary the other direction).
- New describe block: 8 direct tests of renderLinearFinalStatusComment
  covering null-metric fallbacks, formatDuration boundaries (<60s,
  exact-minute Nm, mixed Nm Ss), classifier-title rendering on ⚠️ and
  ❌ frames, and the no-trailing-colon when errorTitle is null.

Total: 102 tests in fanout-task-events.test.ts (was 92), 1826
passing across the full CDK suite.
… dev smoke

After the first dev deploy of the Linear dispatcher (aws-samples#239), two
near-duplicate things showed up on the Linear thread:

1. The platform's ✅ comment carried PR: <url> while the agent's
   step-2 'PR opened' comment had already posted the same link one
   slot earlier. Two clickable copies of the same URL adds noise.
2. The agent's step-3 'task completed' free-form comment stacked
   right next to the platform's ✅ structured comment with full
   metrics. Two completion comments back-to-back with overlapping
   intent — the platform one is strictly more informative.

Changes:

- renderLinearFinalStatusComment: render PR url ONLY on the ⚠️
  shipped-but-stopped-early path. On ✅, the agent's step-2 comment is
  guaranteed to have fired with the PR link; on ⚠️ the agent may have
  crashed before step-2 (e.g. ABCA-91 max-turns on turn 101), so the
  platform comment is the backup signal and the PR url has to be
  there.
- Updated the corresponding test to assert not.toContain on the ✅
  fixture's PR URL.
- Removed step 3 from the Linear-channel prompt contract in
  prompt_builder.py. Replaced with an explicit prohibition against
  posting a final 'task completed/failed' comment, with a sentence
  pointing the agent at the platform fan-out plane (aws-samples#239) as the
  source of truth for terminal status.

Net Linear thread shape post-task: agent posts start (1) + PR-opened
(2); platform posts terminal ✅/⚠️/❌ (3). One PR url, one completion
comment. Krokoko predicted this exact migration in their PR-243
review — 'the agent prompt can drop step 3 once the platform side is
reliable.'

Targeted suite still 102 passing.
@isadeks isadeks force-pushed the feat/239-linear-fanout-dispatcher branch from c9d17f1 to 8603cfa Compare June 10, 2026 19:41
@krokoko krokoko added this pull request to the merge queue Jun 10, 2026
Merged via the queue into aws-samples:main with commit 71a8c31 Jun 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(notifications): platform-side final-status Linear comment with cost/turns/duration

3 participants